Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13장. Collection을 가공하는 함수

백엔드 개발은 결국 “데이터를 다루는 일“입니다.

데이터베이스에서 목록을 꺼내 오고,
그중 필요한 것만 고르고,
원하는 모양으로 바꿔서 화면이나 다른 서버로 넘깁니다.

이런 작업의 대부분은 컬렉션(collection)을 가공하는 일입니다.
리스트, 맵 같은 “값들의 묶음“을 손질하는 것입니다.

코틀린은 이 손질 작업을 위한 함수를 아주 많이 준비해 두었습니다.
이 함수들만 잘 익혀 두면,
반복문으로 길게 쓰던 코드를 한 줄로 줄일 수 있습니다.

이 장에서는 백엔드에서 매일 쓰는
컬렉션 가공 함수들을 하나씩 살펴봅니다.

이 장의 함수들은 대부분 람다(lambda)를 인자로 받습니다.
람다 문법 자체는 14장에서 자세히 다루므로,
여기서는 “중괄호 안에 처리 규칙을 적는다” 정도로
편하게 읽어도 괜찮습니다.

또 하나, 자바를 조금 아는 분이라면
“이거 자바 스트림(Stream)이랑 비슷한데?“라고 느낄 수 있습니다.
맞습니다. 비슷한 일을 합니다.
다만 코틀린은 .stream()을 붙이는 과정 없이
컬렉션에 바로 함수를 부를 수 있어 훨씬 간결합니다.
(둘의 차이는 절마다 짚어 보겠습니다.)


13.1 선택하고 변환하기

컬렉션 가공에서 가장 기본은 두 가지입니다.

  • 필요한 것만 고르기 (선택)
  • 원하는 모양으로 바꾸기 (변환)

이 절에서 배울 filtermap
바로 그 두 가지를 담당하는 대표 함수입니다.

filter

filter는 “조건에 맞는 것만 걸러내는” 함수입니다.

이름 그대로 체(filter)에 걸러내는 모습을 떠올리면 쉽습니다.
조건을 통과한 값만 남고, 나머지는 버려집니다.

중괄호 안에는 “남길 조건“을 적습니다.
조건이 참(true)인 값만 새 리스트에 담깁니다.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val evens = numbers.filter { it % 2 == 0 }

println(evens)
[2, 4, 6]

여기서 it
“지금 검사 중인 값 하나“를 가리키는 이름입니다.
람다에서 인자가 하나일 때 자동으로 쓸 수 있습니다.
(자세한 규칙은 14장에서 다룹니다.)

원래 리스트는 그대로 남습니다.
filter는 결과를 새 리스트로 돌려줄 뿐,
기존 리스트를 바꾸지 않습니다.
2장에서 말한 불변성과 잘 어울리는 방식입니다.

map

map은 “각 값을 다른 값으로 바꾸는” 함수입니다.

리스트의 값 하나하나를
중괄호 안의 규칙대로 변환해 새 리스트를 만듭니다.

val numbers = listOf(1, 2, 3)

val doubled = numbers.map { it * 2 }

println(doubled)
[2, 4, 6]

문자열로 바꾸거나,
객체에서 원하는 값만 꺼낼 때도 자주 씁니다.

val names = listOf("kim", "lee", "park")

val upper = names.map { it.uppercase() }

println(upper)
[KIM, LEE, PARK]

filtermap은 짝을 이뤄 자주 함께 쓰입니다.
“거르고 나서 바꾼다“는 흐름입니다.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val result = numbers
    .filter { it % 2 == 0 }   // 짝수만 남기고
    .map { it * 10 }          // 10을 곱한다

println(result)
[20, 40, 60]

자바에서는 같은 일을
list.stream().filter(...).map(...).collect(...)처럼 씁니다.
코틀린은 .stream().collect()도 필요 없습니다.

mapNotNull

map을 쓰다 보면
“바꾼 결과 중 null은 빼고 싶다“는 상황이 생깁니다.

null은 “값이 없음“을 뜻합니다.
(null에 대해서는 3부에서 자세히 다뤘습니다.)

mapNotNull은 변환한 뒤,
결과가 null인 것은 자동으로 버려 줍니다.

val texts = listOf("1", "2", "삼", "4")

val numbers = texts.mapNotNull { it.toIntOrNull() }

println(numbers)
[1, 2, 4]

toIntOrNull()
숫자로 바꿀 수 없으면 null을 돌려주는 함수입니다.
"삼"은 숫자가 아니라 null이 되고,
mapNotNull이 그 null을 걸러 냅니다.

map 다음에 filter로 null을 지우는 것보다
한 번에 처리해 주니 훨씬 깔끔합니다.

flatMap

flatMap은 조금 특별합니다.

“리스트 안에 리스트가 있을 때,
이를 하나로 펼쳐 주는” 함수입니다.

flat은 “평평하게“라는 뜻입니다.
겹겹이 쌓인 리스트를 평평하게 펴 준다고 생각하면 쉽습니다.

val nested = listOf(
    listOf(1, 2),
    listOf(3, 4),
    listOf(5)
)

val flat = nested.flatMap { it }

println(flat)
[1, 2, 3, 4, 5]

각 요소가 리스트를 품고 있을 때 특히 유용합니다.
예를 들어 “주문마다 상품 목록이 들어 있고,
전체 상품을 하나의 목록으로 모으고 싶을 때“입니다.

data class Order(val id: Int, val items: List<String>)

val orders = listOf(
    Order(1, listOf("사과", "바나나")),
    Order(2, listOf("우유")),
    Order(3, listOf("빵", "계란"))
)

val allItems = orders.flatMap { it.items }

println(allItems)
[사과, 바나나, 우유, 빵, 계란]

map으로 했다면
[[사과, 바나나], [우유], [빵, 계란]]처럼
리스트가 겹쳐진 결과가 나왔을 것입니다.
flatMap은 이를 한 겹으로 펴서 돌려줍니다.


13.2 데이터를 찾고 검사하기

목록을 다루다 보면 이런 질문을 자주 하게 됩니다.

  • 조건에 맞는 값이 있나?
  • 특정 값이 들어 있나?
  • 전부 다 조건을 만족하나?

이 절의 함수들이 그 질문에 답해 줍니다.
대부분 결과로 값 하나나 참/거짓(Boolean)을 돌려줍니다.

find

find는 “조건에 맞는 첫 번째 값을 찾아 주는” 함수입니다.

val numbers = listOf(1, 2, 3, 4, 5)

val firstEven = numbers.find { it % 2 == 0 }

println(firstEven)
2

조건에 맞는 값이 하나도 없으면
find는 null을 돌려줍니다.

val numbers = listOf(1, 3, 5)

val firstEven = numbers.find { it % 2 == 0 }

println(firstEven)
null

그래서 find의 결과는
“값이 없을 수도 있음(nullable)“으로 다뤄야 합니다.

any

any는 “조건에 맞는 값이 하나라도 있는가“를 묻습니다.
결과는 참 또는 거짓입니다.

val numbers = listOf(1, 2, 3)

val hasEven = numbers.any { it % 2 == 0 }

println(hasEven)
true

조건 없이 그냥 any()만 부르면
“비어 있지 않은가“를 확인합니다.

val empty = emptyList<Int>()

println(empty.any())   // false (하나도 없음)

all

all은 “모든 값이 조건을 만족하는가“를 묻습니다.
하나라도 어긋나면 거짓입니다.

val numbers = listOf(2, 4, 6)

val allEven = numbers.all { it % 2 == 0 }

println(allEven)
true

none

noneall의 반대입니다.
“조건에 맞는 값이 하나도 없는가“를 묻습니다.

val numbers = listOf(1, 3, 5)

val noEven = numbers.none { it % 2 == 0 }

println(noEven)
true

any, all, none은 헷갈리기 쉬우니
표로 정리해 두겠습니다.

함수참(true)이 되는 경우
any조건에 맞는 값이 하나라도 있을 때
all모든 값이 조건을 만족할 때
none조건에 맞는 값이 하나도 없을 때

contains

contains는 “특정 값이 들어 있는가“를 묻습니다.
조건이 아니라 값 자체를 넘긴다는 점이 다릅니다.

val fruits = listOf("사과", "바나나", "포도")

println(fruits.contains("바나나"))   // true
println(fruits.contains("수박"))    // false

in 연산자로도 같은 확인을 할 수 있습니다.
읽기에는 이쪽이 더 자연스러울 때가 많습니다.

println("바나나" in fruits)   // true

13.3 데이터를 묶기

데이터를 “종류별로 묶는” 일도 백엔드에서 흔합니다.

  • 사용자를 등급별로 묶기
  • 주문을 상태별로 묶기
  • ID를 열쇠(key)로 삼아 빠르게 찾을 수 있게 만들기

이 절의 함수들은 리스트를
맵(Map) 형태로 바꿔 주는 경우가 많습니다.

맵(Map)은 “열쇠(key)와 값(value)의 짝“으로 이뤄진 자료구조입니다.
사전에서 단어(key)로 뜻(value)을 찾듯,
key로 value를 빠르게 찾을 수 있습니다.

groupBy

groupBy는 “같은 기준을 가진 것끼리 묶는” 함수입니다.
결과는 “기준 → 해당하는 값들의 리스트” 형태의 맵입니다.

val words = listOf("apple", "banana", "avocado", "blueberry", "cherry")

val grouped = words.groupBy { it.first() }

println(grouped)
{a=[apple, avocado], b=[banana, blueberry], c=[cherry]}

it.first()는 단어의 첫 글자입니다.
첫 글자가 같은 단어끼리 리스트로 묶였습니다.

숫자를 짝수/홀수로 묶는 것도 간단합니다.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val grouped = numbers.groupBy { if (it % 2 == 0) "짝수" else "홀수" }

println(grouped)
{홀수=[1, 3, 5], 짝수=[2, 4, 6]}

associate

associate는 “리스트를 맵으로 바꾸는” 함수입니다.
각 값에서 “key와 value의 짝“을 직접 만들어 줍니다.

짝은 key to value 형태로 적습니다.
to는 두 값을 짝으로 묶어 주는 표현입니다.

val names = listOf("kim", "lee", "park")

val lengthMap = names.associate { it to it.length }

println(lengthMap)
{kim=3, lee=3, park=4}

이름을 key로, 이름 길이를 value로 하는 맵이 만들어졌습니다.

associateBy

associateByassociate보다 더 자주 씁니다.

value는 원래 값 그대로 두고,
key만 정해 주면 되기 때문입니다.

data class User(val id: Long, val name: String)

val users = listOf(
    User(1, "kim"),
    User(2, "lee"),
    User(3, "park")
)

val userMap = users.associateBy { it.id }

println(userMap)
{1=User(id=1, name=kim), 2=User(id=2, name=lee), 3=User(id=3, name=park)}

이제 userMap[2]처럼
ID만으로 사용자를 바로 꺼낼 수 있습니다.
이 패턴은 13.7에서 다시 자세히 다룹니다.

groupByassociateBy는 헷갈리기 쉽습니다.
groupBy는 하나의 key에 값이 여러 개(리스트)이고,
associateBy는 하나의 key에 값이 딱 하나입니다.
key가 겹치면 associateBy는 나중 값으로 덮어씁니다.

partition

partition은 “조건에 따라 딱 두 덩어리로 나누는” 함수입니다.

  • 조건을 만족하는 것들
  • 만족하지 않는 것들

결과는 두 리스트의 짝(Pair)으로 돌아옵니다.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val (evens, odds) = numbers.partition { it % 2 == 0 }

println(evens)
println(odds)
[2, 4, 6]
[1, 3, 5]

val (evens, odds) = ... 부분은
짝으로 온 두 값을 한 번에 나눠 받는 문법입니다.
(구조 분해 선언이라고 하며, 나중에 다시 만납니다.)

filter를 두 번 쓸 수도 있지만,
partition은 목록을 한 번만 훑어 두 덩어리로 나눠 줍니다.


13.4 정렬하고 중복 제거하기

목록을 보기 좋게 다듬는 작업입니다.

  • 순서대로 줄 세우기 (정렬)
  • 겹치는 값 없애기 (중복 제거)

sorted

sorted는 “작은 것부터 큰 순서로 줄 세우는” 함수입니다.
숫자나 문자열처럼 크기를 비교할 수 있는 값에 씁니다.

val numbers = listOf(3, 1, 4, 1, 5, 9, 2)

println(numbers.sorted())
[1, 1, 2, 3, 4, 5, 9]

반대로 큰 것부터 줄 세우려면
sortedDescending()을 씁니다.

println(numbers.sortedDescending())
[9, 5, 4, 3, 2, 1, 1]

sortedBy

sortedBy는 “무엇을 기준으로 정렬할지” 정해 줍니다.
객체를 특정 항목 기준으로 정렬할 때 씁니다.

data class User(val name: String, val age: Int)

val users = listOf(
    User("kim", 30),
    User("lee", 20),
    User("park", 25)
)

val byAge = users.sortedBy { it.age }

println(byAge)
[User(name=lee, age=20), User(name=park, age=25), User(name=kim, age=30)]

나이 기준으로 어린 순서대로 정렬됐습니다.
큰 순서로 하려면 sortedByDescending을 씁니다.

val oldestFirst = users.sortedByDescending { it.age }

sortedWith

정렬 기준이 두 개 이상일 때가 있습니다.
예를 들어 “나이 순으로, 나이가 같으면 이름 순으로“입니다.

이럴 때 sortedWithcompareBy를 씁니다.

data class User(val name: String, val age: Int)

val users = listOf(
    User("park", 25),
    User("kim", 25),
    User("lee", 20)
)

val sorted = users.sortedWith(
    compareBy({ it.age }, { it.name })
)

println(sorted)
[User(name=lee, age=20), User(name=kim, age=25), User(name=park, age=25)]

compareBy에 기준을 순서대로 나열하면,
앞의 기준이 같을 때 다음 기준으로 비교합니다.
나이가 같은 park와 kim이 이름 순으로 정렬됐습니다.

정렬 함수들을 표로 정리하면 이렇습니다.

함수쓰임
sorted값 자체를 기본 순서로 정렬
sortedBy기준 하나를 정해 정렬
sortedWith기준 여러 개를 조합해 정렬

distinct

distinct는 “중복을 없애는” 함수입니다.
같은 값이 여러 번 있으면 하나만 남깁니다.

val numbers = listOf(1, 2, 2, 3, 3, 3, 4)

println(numbers.distinct())
[1, 2, 3, 4]

distinctBy

distinctBy는 “무엇을 기준으로 중복을 볼지” 정해 줍니다.
객체에서 특정 항목이 같으면 중복으로 봅니다.

data class User(val id: Long, val name: String)

val users = listOf(
    User(1, "kim"),
    User(1, "kim"),
    User(2, "lee")
)

val unique = users.distinctBy { it.id }

println(unique)
[User(id=1, name=kim), User(id=2, name=lee)]

ID가 같은 첫 번째 사용자만 남고,
중복된 ID는 제거됐습니다.


13.5 집계하기

집계(aggregation)란
“여러 값을 하나의 결과로 요약하는” 것입니다.

  • 개수 세기
  • 합계 구하기
  • 가장 큰 값, 가장 작은 값 찾기

주문 금액 합계, 사용자 수 같은
백엔드 통계에서 늘 쓰는 작업입니다.

count

count는 “개수를 세는” 함수입니다.
그냥 부르면 전체 개수를 셉니다.

val numbers = listOf(1, 2, 3, 4, 5)

println(numbers.count())   // 5

조건을 주면 “조건에 맞는 것의 개수“를 셉니다.

val evenCount = numbers.count { it % 2 == 0 }

println(evenCount)   // 2

sum

sum은 “숫자를 모두 더하는” 함수입니다.
숫자 리스트에 바로 쓸 수 있습니다.

val numbers = listOf(10, 20, 30)

println(numbers.sum())   // 60

sumOf

sumOf는 “무엇을 더할지” 정해 줍니다.
객체에서 특정 숫자를 꺼내 더할 때 씁니다.

data class Order(val product: String, val price: Int)

val orders = listOf(
    Order("사과", 1000),
    Order("우유", 2500),
    Order("빵", 1500)
)

val total = orders.sumOf { it.price }

println(total)   // 5000

map으로 가격만 뽑은 뒤 sum을 부를 수도 있지만,
sumOf가 한 번에 처리해 더 깔끔합니다.

minOrNull

minOrNull은 “가장 작은 값을 찾는” 함수입니다.

이름 끝에 OrNull이 붙은 이유가 있습니다.
리스트가 비어 있으면 최솟값이 없으므로,
그럴 때 null을 돌려주기 때문입니다.

val numbers = listOf(3, 1, 4, 1, 5)

println(numbers.minOrNull())   // 1

val empty = emptyList<Int>()
println(empty.minOrNull())     // null

기준을 정해 최솟값을 찾으려면
minByOrNull을 씁니다.

data class User(val name: String, val age: Int)

val users = listOf(
    User("kim", 30),
    User("lee", 20),
    User("park", 25)
)

val youngest = users.minByOrNull { it.age }

println(youngest)   // User(name=lee, age=20)

maxOrNull

maxOrNull은 반대로 “가장 큰 값“을 찾습니다.
사용법은 minOrNull과 똑같습니다.

val numbers = listOf(3, 1, 4, 1, 5)

println(numbers.maxOrNull())   // 5

기준이 필요하면 maxByOrNull을 씁니다.

val oldest = users.maxByOrNull { it.age }

println(oldest)   // User(name=kim, age=30)

OrNull이 붙은 함수는
“결과가 없을 수도 있음(null)“을 뜻합니다.
빈 리스트를 다룰 때 오류 없이 안전하게 처리할 수 있습니다.


13.6 누적 계산

앞 절의 집계 함수는
합계나 최댓값처럼 정해진 계산만 해 줬습니다.

그런데 “내가 원하는 방식으로
값을 하나씩 누적하고 싶을 때“가 있습니다.

이때 쓰는 것이 foldreduce입니다.
두 함수는 값을 하나씩 훑으며 결과를 쌓아 갑니다.

fold

fold는 “시작값을 정하고,
값을 하나씩 누적해 가는” 함수입니다.

두 가지를 넘깁니다.

  • 시작값 (초깃값)
  • 누적 규칙 (지금까지의 결과 + 다음 값)
val numbers = listOf(1, 2, 3, 4)

val sum = numbers.fold(0) { acc, num -> acc + num }

println(sum)   // 10

여기서 acc는 accumulator,
즉 “지금까지 쌓인 결과“입니다.
num은 “지금 처리 중인 값“입니다.

계산 과정을 풀어 보면 이렇습니다.

시작값 0
0 + 1 = 1
1 + 2 = 3
3 + 3 = 6
6 + 4 = 10

fold의 장점은 시작값을 정할 수 있다는 것입니다.
그래서 결과 타입을 자유롭게 바꿀 수 있습니다.
예를 들어 숫자 리스트를 하나의 문자열로 이어 붙일 수 있습니다.

val numbers = listOf(1, 2, 3)

val text = numbers.fold("숫자:") { acc, num -> "$acc $num" }

println(text)   // 숫자: 1 2 3

reduce

reducefold와 비슷하지만
시작값이 없습니다.

첫 번째 값을 시작값으로 삼고,
두 번째 값부터 누적합니다.

val numbers = listOf(1, 2, 3, 4)

val sum = numbers.reduce { acc, num -> acc + num }

println(sum)   // 10

간단해 보이지만 주의할 점이 있습니다.
리스트가 비어 있으면 시작값도 없으므로
reduce는 오류를 냅니다.

시작값이 필요하거나
빈 리스트가 들어올 수 있다면 fold를 쓰세요.
리스트가 비어 있지 않다고 확신할 때만 reduce를 쓰는 편이 안전합니다.

foldreduce를 표로 비교하면 이렇습니다.

구분foldreduce
시작값직접 정함첫 번째 값을 사용
결과 타입자유롭게 바꿀 수 있음원소와 같은 타입
빈 리스트안전 (시작값 반환)오류 발생

13.7 백엔드 데이터 가공 예제

이제 앞에서 배운 함수들을
실제 백엔드 상황에 적용해 봅시다.

여기 나오는 패턴은
스프링으로 서버를 만들 때 매일 마주치는 것들입니다.
지금 손에 익혀 두면 큰 도움이 됩니다.

먼저 예제에서 공통으로 쓸 데이터를 정의하겠습니다.

data class User(val id: Long, val name: String, val grade: String)

data class Order(
    val id: Long,
    val userId: Long,
    val product: String,
    val amount: Int
)

사용자 ID를 기준으로 Map 만들기

서버에서는 목록을 받은 뒤
“ID로 특정 항목을 빠르게 찾고 싶을 때“가 아주 많습니다.

리스트에서 매번 find로 찾으면
항목이 많을수록 느려집니다.
그래서 미리 맵으로 만들어 두면 편리합니다.

associateBy가 바로 이때 쓰입니다.

val users = listOf(
    User(1, "kim", "VIP"),
    User(2, "lee", "BASIC"),
    User(3, "park", "VIP")
)

val userMap = users.associateBy { it.id }

// 이제 ID로 바로 꺼낼 수 있다
println(userMap[2]?.name)   // lee

userMap[2] 뒤의 ?
“해당 ID가 없을 수도 있음“을 대비한 것입니다.
(null 안전 호출은 3부에서 다뤘습니다.)

주문별 상품 그룹화

“사용자마다 어떤 주문을 했는지” 묶고 싶을 때가 있습니다.
한 사용자가 주문을 여러 개 할 수 있으므로,
key 하나에 값이 여러 개 필요합니다.

이럴 때는 groupBy를 씁니다.

val orders = listOf(
    Order(1, userId = 1, product = "사과", amount = 1000),
    Order(2, userId = 1, product = "우유", amount = 2500),
    Order(3, userId = 2, product = "빵", amount = 1500)
)

val ordersByUser = orders.groupBy { it.userId }

println(ordersByUser[1]?.size)   // 2 (사용자 1의 주문 수)

사용자 ID별로 상품 이름만 모으고 싶다면
groupBy에 두 번째 인자를 더할 수 있습니다.

val productsByUser = orders.groupBy(
    keySelector = { it.userId },
    valueTransform = { it.product }
)

println(productsByUser)
{1=[사과, 우유], 2=[빵]}

keySelector는 묶는 기준(key),
valueTransform은 담을 값의 모양을 정합니다.

결제 금액 집계

통계는 백엔드의 단골 업무입니다.
“오늘 총 매출은 얼마인가” 같은 질문에 답해야 합니다.

전체 매출은 sumOf로 간단히 구합니다.

val totalAmount = orders.sumOf { it.amount }

println(totalAmount)   // 5000

“사용자별 매출“처럼
그룹마다 합계를 내고 싶을 때는
groupBymapValues를 함께 씁니다.

val amountByUser = orders
    .groupBy { it.userId }
    .mapValues { (_, orderList) -> orderList.sumOf { it.amount } }

println(amountByUser)
{1=3500, 2=1500}

mapValues는 맵의 값(value)만 바꿔 주는 함수입니다.
여기서는 “주문 리스트“를 “합계 금액“으로 바꿨습니다.
(_, orderList)에서 _
“key는 쓰지 않겠다“는 표시입니다.

조건을 건 집계도 흔합니다.
예를 들어 “2000원 이상 주문의 개수“입니다.

val bigOrderCount = orders.count { it.amount >= 2000 }

println(bigOrderCount)   // 1

외부 API 결과 변환하기

백엔드는 다른 서버(외부 API)에서
데이터를 받아 오는 일이 잦습니다.

그런데 외부에서 온 데이터의 모양이
우리 서비스에 딱 맞지 않을 때가 많습니다.
그래서 “우리가 쓰기 좋은 형태로 바꾸는” 작업이 필요합니다.

외부에서 이런 응답이 왔다고 해 봅시다.

data class ExternalUser(
    val user_id: String,      // 문자열로 온 ID
    val user_name: String,
    val status: String        // "ACTIVE" 또는 "INACTIVE"
)

우리 서비스는 이런 형태를 쓴다고 합시다.

data class Member(val id: Long, val name: String, val active: Boolean)

이제 filter, map, mapNotNull을 조합해
필요한 것만 골라 우리 형태로 바꿔 보겠습니다.

val externalUsers = listOf(
    ExternalUser("1", "kim", "ACTIVE"),
    ExternalUser("2", "lee", "INACTIVE"),
    ExternalUser("abc", "park", "ACTIVE")   // ID가 숫자가 아님
)

val members = externalUsers
    .filter { it.status == "ACTIVE" }        // 활성 사용자만
    .mapNotNull { external ->
        val id = external.user_id.toLongOrNull()
            ?: return@mapNotNull null        // ID 변환 실패 시 제외
        Member(
            id = id,
            name = external.user_name,
            active = true
        )
    }

println(members)
[Member(id=1, name=kim, active=true)]

무슨 일이 벌어졌는지 정리해 봅시다.

  • lee는 INACTIVE라서 filter에서 걸러졌습니다
  • park는 ID "abc"가 숫자로 안 바뀌어 mapNotNull에서 제외됐습니다
  • kim만 우리 형태(Member)로 변환돼 남았습니다

?: 부분은 엘비스 연산자입니다.
“왼쪽이 null이면 오른쪽을 실행하라“는 뜻입니다.
(자세한 내용은 3부에서 다뤘습니다.)

이렇게 컬렉션 함수를 엮으면
“거르고, 변환하고, 다듬는” 일련의 과정을
반복문 없이 읽기 쉽게 표현할 수 있습니다.

이 예제들은 처리할 데이터가 많아지면
성능을 더 신경 써야 할 때가 옵니다.
그때는 중간 리스트를 만들지 않는 시퀀스(Sequence)가 유용합니다.
시퀀스는 16장에서 다룹니다.


13장을 마치며

이 장에서 우리는
컬렉션을 가공하는 여러 함수를 배웠습니다.

  • 선택과 변환: filter, map, mapNotNull, flatMap
  • 찾기와 검사: find, any, all, none, contains
  • 묶기: groupBy, associate, associateBy, partition
  • 정렬과 중복 제거: sorted, sortedBy, sortedWith, distinct, distinctBy
  • 집계: count, sum, sumOf, minOrNull, maxOrNull
  • 누적 계산: fold, reduce

함수가 많아 보이지만 걱정하지 마세요.
전부 외울 필요는 없습니다.

중요한 것은
“이런 일을 하는 함수가 있다“는 감각입니다.
필요할 때 이 장을 다시 펼쳐 찾아 쓰면 됩니다.

이 함수들의 공통점은
원본을 바꾸지 않고 새 결과를 돌려준다는 것입니다.
2장에서 배운 불변성과 자연스럽게 이어집니다.

다음 장에서는
이 함수들의 중괄호 안에 들어가던
람다(lambda)를 제대로 파헤쳐 봅니다.
람다를 이해하면 이 장의 함수들이
어떻게 동작하는지 훨씬 선명하게 보일 것입니다.